iT邦幫忙

2023 iThome 鐵人賽

DAY 23
0
Mobile Development

Spring Boot+Android 30天 實戰開發 系列 第 23

【Day - 23】Spring Security 6.1.x:實現JWT身份驗證 (中)

  • 分享至 

  • xImage
  •  

4. Spring Security 與 JWT 整合

在本節中,我們將深入探討如何使用Spring Security實現JWT身份驗證,以及如何配置和設置Spring Security來簽發和驗證JWT。此外,我們還將討論如何使用Spring Security來保護您的後端API,以及限制資源的訪問。

4.1 配置 Spring Security

首先,我們需要配置Spring Security以使用JWT進行身份驗證。以下是一些示例配置的步驟:

4.1.1 添加依賴

確保您的項目中包含了Spring Security和相關的JWT庫的依賴。您可以在pom.xml中添加以下依賴:

<!-- Spring Security依賴 3.1.4對應到的核心版本為6.1.4 -->
<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-security</artifactId>
    <version>3.1.4</version>   
</dependency>

<!--  [Tools]JJwt JWT依賴  -->
<dependency>
    <groupId>io.jsonwebtoken</groupId>
    <artifactId>jjwt-api</artifactId>
    <version>0.11.5</version>
</dependency>
<dependency>
    <groupId>io.jsonwebtoken</groupId>
    <artifactId>jjwt-impl</artifactId>
    <version>0.11.5</version>
</dependency>
<dependency>
    <groupId>io.jsonwebtoken</groupId>
    <artifactId>jjwt-jackson</artifactId>
    <version>0.11.5</version>
</dependency>

<!-- ~~以下為其他依賴~~ -->
<!--  [Web]Spring Web  -->
<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-web</artifactId>
</dependency>

<!--  [Tools]Lombok  -->
<dependency>
    <groupId>org.projectlombok</groupId>
    <artifactId>lombok</artifactId>
    <optional>true</optional>
</dependency>

4.1.2 配置 Spring Security

在Spring Boot應用程式中,您可以通過創建一個SecurityConfig類來配置Spring Security。以下是一個簡單的配置示例:

@Configuration
@EnableWebSecurity
@RequiredArgsConstructor //Lombok註解,詳細介紹在這個系列的第8天
public class SecruityConfig {

    private final JwtAuthenticationFilter jwtAuthFilter; //於"4.2"節實現,此過濾器用於攔截JWT相關請求
    private final AuthenticationProvider authenticationProvider;
    @Bean
    public SecurityFilterChain filterChain(HttpSecurity http, HandlerMappingIntrospector introspector) throws Exception {
        MvcRequestMatcher.Builder mvcMatcherBuilder = new MvcRequestMatcher.Builder(introspector).servletPath("/");

        http.csrf(csrf->csrf.disable())
            .authorizeHttpRequests(auth ->
                auth.requestMatchers(
                      "/error/**",
                      "/api/register",             //用戶註冊
                      "/api/register/check",       //用戶註冊確認
                      "/api/auth",                 //用戶登入
                      "/api/password/forgot",      //忘記密碼
                      "/api/verification/send",    //發送驗證碼
                      "/api/verification/check"   //檢查驗證碼
                ).permitAll() 
                // 其他路徑需要認證
                .anyRequest().authenticated()
            )
            .sessionManagement(sessionManagement -> {
                sessionManagement.sessionCreationPolicy(SessionCreationPolicy.STATELESS); // 無狀態
            })
            .authenticationProvider(authenticationProvider) // 認證提供者
            .addFilterBefore(jwtAuthenticationFilter, UsernamePasswordAuthenticationFilter.class);
        return http.build();
    }
}

4.2 實現自定義JWT過濾器

上述配置中使用了JwtAuthenticationFilter,這是一個自定義的Spring Security配置,用於整合JWT驗證。以下是一個簡單的示例:

@Component
@RequiredArgsConstructor
@Slf4j
public class JwtAuthenticationFilter extends OncePerRequestFilter {

    private final JwtService jwtService;
    private final UserDetailsService userDetailsService;

    @Override
    protected void doFilterInternal(
        @NonNull HttpServletRequest request,
        @NonNull HttpServletResponse response,
        @NonNull FilterChain filterChain
    ) throws ServletException, IOException {
        final String authHeader = request.getHeader("Authorization");
        final String jwt;
        final String userEmail;

        // 以下條件為沒有攜帶Token的請求
        if (authHeader == null || !authHeader.startsWith("Bearer ")) {
            filterChain.doFilter(request, response);
            return;
        }

        jwt = authHeader.substring(7); //從索引7開始取 "Bearer " 後面的Token
        userEmail = jwtService.extractUsername(jwt);
        if (userEmail != null && SecurityContextHolder.getContext().getAuthentication() == null) {
            try {
                UserDetails userDetails = this.userDetailsService.loadUserByUsername(userEmail);
                if (jwtService.isTokenValid(jwt, userDetails)) {
                    UsernamePasswordAuthenticationToken authToken = new UsernamePasswordAuthenticationToken(
                            userDetails,
                            null,
                            userDetails.getAuthorities()
                    );
                    authToken.setDetails(
                            new WebAuthenticationDetailsSource().buildDetails(request)
                    );
                    SecurityContextHolder.getContext().setAuthentication(authToken);
                }else {
                    response.setStatus(HttpServletResponse.SC_OK);
                    response.setContentType("application/json");
                    String errorMessage = "Token has expired";
                    String jsonErrorMessage = "{\"status\": \"1\", \"message\": \"" + errorMessage + "\"}";
                    response.getWriter().write(jsonErrorMessage);
                    return;
                }
            }catch (ExpiredJwtException ex){
                throw ex;
            }
        }
        filterChain.doFilter(request, response);
    }
}

4.3 JWT 簽發和驗證:創建JWT Service

配置Spring Security後,我們需要實現JWT的簽發和驗證邏輯,以下為JwtService的程式碼示例:

@Service
public class JwtService {
    // Token有效期限
    @Value("${conf.token.expiration}") // 透過文件配置的方式給值,詳細教學在此系列的第7天文章
    private Long EXPIRATION_TIME; //單位ms
    // 上述兩行可以改寫為下面這行
    // private Long EXPIRATION_TIME = 900000L
    
    @Value("${conf.token.secret}") // 透過文件配置的方式給值
    private String SECRET_KEY;
    // 上述兩行可以改寫為下面這行
    // private String SECRET_KEY = "你的私鑰" //在這個範例中我使用的簽名算法為(HS256)"SignatureAlgorithm.HS256",我們可以透過線上的密碼產生器,產生長度64的任意字元組成的字串。注意!如果你使用的是其他算法,則你需要給定該算法對應的私鑰規則,具體可以上網查詢

    public String extractUsername(String token) {
        try {
            return extractClaim(token, Claims::getSubject);
        }catch (ExpiredJwtException e){
            return e.getClaims().getSubject();
        }
    }

    public <T> T extractClaim(String token, Function<Claims, T> claimsResolver) {
        final Claims claims = extractAllClaims(token);
        return claimsResolver.apply(claims);
    }

    public String generateToken(UserDetails userDetails) {
        return generateToken(new HashMap<>(), userDetails);
    }

    /**
     * 簽發Token
     */
    public String generateToken(
        Map<String, Object> extractClaims,
        UserDetails userDetails
    ) {
        return Jwts
            .builder()
            .setClaims(extractClaims)
            .setSubject(userDetails.getUsername()) //以Username做為Subject
            .setIssuedAt(new Date(System.currentTimeMillis()))
            .setExpiration(new Date(System.currentTimeMillis() + EXPIRATION_TIME))
            .signWith(getSignInKey(), SignatureAlgorithm.HS256)
            .compact();
    }

    /**
     * 驗證Token有效性,比對JWT和UserDetails的Username(Email)是否相同
     * @return 有效為True,反之False
     */
    public boolean isTokenValid(String token, UserDetails userDetails) {
        final String username = extractUsername(token);
        return (username.equals(userDetails.getUsername())) && !isTokenExpired(token);
    }

    private boolean isTokenExpired(String token) {
        final Date expirationDate = extractExpiration(token);
//        return extractExpiration(token).before(new Date());
        return expirationDate != null && expirationDate.before(new Date());
    }

    private Date extractExpiration(String token) {
        return extractClaim(token, Claims::getExpiration);
    }

    /**
     * 獲取令牌中所有的聲明
     * @return 令牌中所有的聲明
     */
    private Claims extractAllClaims(String token) {
        try {
            return Jwts
                    .parserBuilder()
                    .setSigningKey(getSignInKey())
                    .build()
                    .parseClaimsJws(token)
                    .getBody();
        }
        catch (ExpiredJwtException e){
            return e.getClaims();
        }
    }

    private Key getSignInKey() {
        byte[] keyBytes = Decoders.BASE64.decode(SECRET_KEY);
        return Keys.hmacShaKeyFor(keyBytes);
    }
}

這就完成了Spring Security和JWT的整合,使您能夠實現安全的身份驗證和授權,並保護您的API資源。

4.4 使用Postman測試是否成功

如下圖:
圖片無法顯示

5. JWT 刷新令牌

在使用 JWT 進行身份驗證時,探討實現 JWT 刷新令牌的方法至關重要。刷新令牌是一個關鍵的概念,它允許使用者在令牌過期後仍然保持登入狀態,而無需重新輸入使用者名稱和密碼。在這一節中,我們將探討不同的實現方法,以及每種方法的優點和限制。

方法一:雙令牌

  • 刷新令牌方法的工作流程大致如下:
    1. 使用者登入並獲得訪問令牌(Access Token)和刷新令牌(Refresh Token)。
    2. 訪問令牌用於訪問受保護的資源,並具有較短的有效期,通常在幾分鐘到幾小時之間。
    3. 當訪問令牌過期時,使用者可以使用刷新令牌向身份驗證伺服器發出請求,以獲取新的訪問令牌。
    4. 身份驗證伺服器驗證刷新令牌的有效性,如果有效,生成並返回新的訪問令牌。
    5. 使用者可以使用新的訪問令牌繼續訪問受保護的資源。
      如果刷新令牌也過期,則使用者需要重新登入。

方法二:單令牌

  • 單令牌來刷新令牌方法的工作流程相對簡單,如下所示:
    1. 使用者登入並獲得訪問令牌(Access Token)。
      2.訪問令牌用於訪問受保護的資源,並具有較短的有效期,通常在幾分鐘到幾小時之間。
    2. 當訪問令牌過期時,使用者需要重新登入,獲取新的訪問令牌。
    3. 在這種方法中,刷新令牌的概念被省略,使用者只需重新登入來獲得新的訪問令牌。這簡化了伺服器端的邏輯,但可能會影響使用者體驗,因為使用者需要在訪問令牌過期時重新輸入使用者名稱和密碼。

選擇哪種方法取決於您的應用程序需求和安全性考量。方法一提供了更長時間的登入狀態,但需要額外的伺服器邏輯和刷新令牌的管理。方法二簡化了伺服器端的邏輯,但可能需要用戶在訪問令牌過期時重新登入。根據您的情況,您可以選擇最適合您應用程式的方法。


上一篇
【Day - 22】Spring Security 6.1.x:實現JWT身份驗證 (上)
下一篇
【Day - 24】Spring Security 6.1.x JWT身份驗證 (下):透過Redis實作登出功能
系列文
Spring Boot+Android 30天 實戰開發 30
圖片
  直播研討會
圖片
{{ item.channelVendor }} {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言